完整的缓存键格式:{keyPrefix}{channelCode}
*/
private String keyPrefix = "gateway:channel:";
/**
* 缓存过期时间(秒)
*
建议设置为 1 小时(3600秒),
* 在密钥更新时需要主动清除缓存
*/
private long expireSeconds = 3600;
}
}
```
## **4. 数据模型**
### **4.1 ChannelDataVo.java - 渠道数据模型**
```java
package com.example.gateway.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serial;
import java.io.Serializable;
/**
* 渠道数据模型
*
*
渠道的可读名称,用于日志和监控展示
*/
private String name;
/**
* 签名公钥(Base64编码)
*
*
禁用的渠道将拒绝所有请求
*/
private Integer status;
/**
* 判断渠道是否启用
* @return true-启用,false-禁用
*/
public boolean isEnabled() {
return status != null && status == 1;
}
}
```
### **4.2 GatewayRequest.java - 统一请求模型**
```java
package com.example.gateway.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 统一网关请求模型
*
*
{
/**
* 响应码
*
* - 200 - 成功
* - 400 - 请求参数错误
* - 401 - 未授权(签名验证失败)
* - 403 - 禁止访问(渠道被禁用)
* - 500 - 服务器内部错误
*
*/
private Integer code;
/**
* 响应消息
*/
private String message;
/**
* 响应数据
* V1模式:明文对象
*
V2模式:加密后的字符串
*/
private T data;
/**
* 构建成功响应
*/
public static GatewayResponse success(T data) {
return GatewayResponse.builder()
.code(200)
.message("success")
.data(data)
.build();
}
/**
* 构建错误响应
*/
public static GatewayResponse error(Integer code, String message) {
return GatewayResponse.builder()
.code(code)
.message(message)
.build();
}
}
```
## **5. RSA 工具类**
```java
package com.example.gateway.utils;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.binary.Base64;
import javax.crypto.Cipher;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Map;
import java.util.TreeMap;
/**
* RSA 加解密工具类
*
* 提供完整的 RSA 签名验证和数据加解密功能,支持:
*
* - SHA256withRSA 签名与验签
* - RSA 分段加解密(支持任意长度数据)
* - 签名内容的标准化构建
*
*
* RSA 密钥长度与加解密限制:
*
* | 密钥长度 | 最大加密长度 | 解密块大小 |
* | 1024 bit | 117 bytes | 128 bytes |
* | 2048 bit | 245 bytes | 256 bytes |
*
* 本工具类使用分段加解密,突破长度限制。
*
*
使用示例:
* {@code
* // 验签
* Map params = new HashMap<>();
* params.put("code", "APP_IOS");
* params.put("timestamp", 1703318400000L);
* params.put("businessBody", "...");
* boolean valid = RsaUtils.verify(params, publicKey, sign);
*
* // 解密
* String plainText = RsaUtils.decrypt(cipherText, privateKey);
*
* // 加密
* String cipherText = RsaUtils.encrypt(plainText, publicKey);
* }
*
* @author Your Name
* @since 1.0.0
*/
@Slf4j
public final class RsaUtils {
// ==================== 常量定义 ====================
/**
* 签名算法:SHA256withRSA
* 使用 SHA-256 计算摘要,再用 RSA 私钥加密
*/
public static final String SIGN_ALGORITHM = "SHA256withRSA";
/**
* 密钥算法:RSA
*/
public static final String KEY_ALGORITHM = "RSA";
/**
* 加密转换方式
*
ECB 模式 + PKCS1 填充,这是 RSA 的默认且最常用的组合
*/
public static final String CIPHER_ALGORITHM = "RSA/ECB/PKCS1Padding";
/**
* RSA 密钥长度(bit)
*
2048 位是目前推荐的安全长度
*/
public static final int KEY_SIZE = 2048;
/**
* RSA 最大加密明文长度(2048位密钥)
*
计算公式:密钥长度/8 - 11(PKCS1填充占用11字节)
*
2048/8 - 11 = 245 字节
*/
private static final int MAX_ENCRYPT_BLOCK = KEY_SIZE / 8 - 11;
/**
* RSA 最大解密密文长度(2048位密钥)
*
等于密钥长度/8 = 256 字节
*/
private static final int MAX_DECRYPT_BLOCK = KEY_SIZE / 8;
// ==================== 私有构造函数 ====================
/**
* 私有构造函数,防止实例化
*/
private RsaUtils() {
throw new UnsupportedOperationException("Utility class cannot be instantiated");
}
// ==================== 签名相关方法 ====================
/**
* 构建签名内容
*
*
将请求参数按照 key 的字典序排序后,拼接成标准格式的签名原文。
* 这是保证前后端签名一致的关键步骤。
*
*
处理规则:
*
* - 使用 TreeMap 自动按 key 字典序排序
* - 排除 sign 字段(签名字段本身不参与签名)
* - 排除 null 和空字符串值
* - 拼接格式:key1=value1&key2=value2
*
*
* 示例:
*
* 输入:{code=APP, timestamp=123, businessBody=data, sign=xxx}
* 排序:businessBody, code, timestamp(字典序)
* 输出:businessBody=data&code=APP×tamp=123
*
*
* @param params 请求参数Map
* @return 签名原文字符串
*/
public static String buildSignContent(Map params) {
if (params == null || params.isEmpty()) {
return "";
}
// 使用 TreeMap 自动按 key 字典序排序
Map sortedMap = new TreeMap<>(params);
StringBuilder content = new StringBuilder();
for (Map.Entry entry : sortedMap.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
// 排除 sign 字段和空值
if (!"sign".equals(key) && value != null && !"".equals(value.toString())) {
if (content.length() > 0) {
content.append("&");
}
content.append(key).append("=").append(value);
}
}
return content.toString();
}
/**
* 验证签名
*
* 使用公钥验证请求的签名是否有效。这是安全校验的第一道防线。
*
*
验签流程:
*
* - 按照 {@link #buildSignContent} 规则构建签名原文
* - 将公钥字符串转换为 PublicKey 对象
* - 使用 SHA256withRSA 算法验证签名
*
*
* 为什么要先验签再解密?(Fail-Fast 原则)
* 验签的计算成本远低于解密。如果先解密,攻击者可以发送大量
* 无效请求来消耗服务器的 CPU 资源(DDoS攻击)。先验签可以快速
* 过滤掉无效请求。
*
* @param params 请求参数Map(包含待验证的数据)
* @param publicKeyStr Base64编码的公钥字符串
* @param sign Base64编码的签名字符串
* @return true-验签成功,false-验签失败
*/
public static boolean verify(Map params, String publicKeyStr, String sign) {
if (params == null || publicKeyStr == null || sign == null) {
log.warn("验签参数不完整: params={}, publicKey={}, sign={}",
params != null, publicKeyStr != null, sign != null);
return false;
}
try {
// 1. 构建签名原文
String content = buildSignContent(params);
log.debug("验签原文: {}", content);
// 2. 解码公钥
byte[] keyBytes = Base64.decodeBase64(publicKeyStr);
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);
PublicKey publicKey = keyFactory.generatePublic(keySpec);
// 3. 验证签名
Signature signature = Signature.getInstance(SIGN_ALGORITHM);
signature.initVerify(publicKey);
signature.update(content.getBytes(StandardCharsets.UTF_8));
boolean result = signature.verify(Base64.decodeBase64(sign));
log.debug("验签结果: {}", result);
return result;
} catch (Exception e) {
log.error("验签异常: {}", e.getMessage(), e);
return false;
}
}
/**
* 使用私钥对数据签名
*
* 生成请求的数字签名,通常由前端调用。后端也可用于测试。
*
* @param params 待签名的参数Map
* @param privateKeyStr Base64编码的私钥字符串
* @return Base64编码的签名字符串
* @throws RuntimeException 签名失败时抛出
*/
public static String sign(Map params, String privateKeyStr) {
try {
// 1. 构建签名原文
String content = buildSignContent(params);
log.debug("签名原文: {}", content);
// 2. 解码私钥
byte[] keyBytes = Base64.decodeBase64(privateKeyStr);
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);
PrivateKey privateKey = keyFactory.generatePrivate(keySpec);
// 3. 执行签名
Signature signature = Signature.getInstance(SIGN_ALGORITHM);
signature.initSign(privateKey);
signature.update(content.getBytes(StandardCharsets.UTF_8));
byte[] signBytes = signature.sign();
return Base64.encodeBase64String(signBytes);
} catch (Exception e) {
throw new RuntimeException("RSA签名失败", e);
}
}
// ==================== 加解密相关方法 ====================
/**
* 公钥加密(支持分段加密)
*
* 使用公钥对明文进行加密。本方法支持任意长度的数据,
* 内部会自动进行分段处理。
*
*
使用场景:
*
* - 后端使用前端数据公钥加密响应数据
* - 前端使用后端数据公钥加密请求数据
*
*
* 分段加密原理:
* RSA 加密的数据长度有限制(2048位密钥最多加密245字节)。
* 对于超长数据,需要分段加密后拼接。
*
* @param content 待加密的明文字符串
* @param publicKeyStr Base64编码的公钥字符串
* @return Base64编码的密文字符串
* @throws RuntimeException 加密失败时抛出
*/
public static String encrypt(String content, String publicKeyStr) {
if (content == null || content.isEmpty()) {
return content;
}
try {
// 1. 解码公钥
byte[] keyBytes = Base64.decodeBase64(publicKeyStr);
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);
PublicKey publicKey = keyFactory.generatePublic(keySpec);
// 2. 初始化加密器
Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
// 3. 分段加密
byte[] data = content.getBytes(StandardCharsets.UTF_8);
byte[] encryptedData = doFinalWithBlock(cipher, data, MAX_ENCRYPT_BLOCK);
// 4. Base64 编码
return Base64.encodeBase64String(encryptedData);
} catch (Exception e) {
throw new RuntimeException("RSA加密失败", e);
}
}
/**
* 私钥解密(支持分段解密)
*
*
使用私钥对密文进行解密。本方法支持任意长度的加密数据,
* 内部会自动进行分段处理。
*
*
使用场景:
*
* - 后端使用后端数据私钥解密请求中的 businessBody
* - 前端使用前端数据私钥解密响应中的 data
*
*
* @param content Base64编码的密文字符串
* @param privateKeyStr Base64编码的私钥字符串
* @return 解密后的明文字符串
* @throws RuntimeException 解密失败时抛出
*/
public static String decrypt(String content, String privateKeyStr) {
if (content == null || content.isEmpty()) {
return content;
}
try {
// 1. 解码私钥
byte[] keyBytes = Base64.decodeBase64(privateKeyStr);
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM);
PrivateKey privateKey = keyFactory.generatePrivate(keySpec);
// 2. 初始化解密器
Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM);
cipher.init(Cipher.DECRYPT_MODE, privateKey);
// 3. 分段解密
byte[] encryptedData = Base64.decodeBase64(content);
byte[] decryptedData = doFinalWithBlock(cipher, encryptedData, MAX_DECRYPT_BLOCK);
// 4. 转换为字符串
return new String(decryptedData, StandardCharsets.UTF_8);
} catch (Exception e) {
throw new RuntimeException("RSA解密失败: " + e.getMessage(), e);
}
}
/**
* 分段处理加解密
*
* 将数据按指定块大小分段处理,解决 RSA 长度限制问题。
*
* @param cipher 加密/解密器
* @param data 待处理的数据
* @param blockSize 每块的最大长度
* @return 处理后的数据
* @throws Exception 处理异常
*/
private static byte[] doFinalWithBlock(Cipher cipher, byte[] data, int blockSize)
throws Exception {
int inputLen = data.length;
int offset = 0;
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
while (inputLen > offset) {
int len = Math.min(inputLen - offset, blockSize);
byte[] block = cipher.doFinal(data, offset, len);
out.write(block);
offset += len;
}
return out.toByteArray();
}
}
// ==================== 密钥生成方法 ====================
/**
* 生成 RSA 密钥对
*
*
生成用于签名/加密的 RSA 密钥对。建议使用 2048 位密钥长度。
*
*
使用示例:
* {@code
* KeyPair keyPair = RsaUtils.generateKeyPair();
* String publicKey = Base64.encodeBase64String(keyPair.getPublic().getEncoded());
* String privateKey = Base64.encodeBase64String(keyPair.getPrivate().getEncoded());
* }
*
* @return RSA密钥对
* @throws RuntimeException 生成失败时抛出
*/
public static KeyPair generateKeyPair() {
try {
KeyPairGenerator generator = KeyPairGenerator.getInstance(KEY_ALGORITHM);
generator.initialize(KEY_SIZE, new SecureRandom());
return generator.generateKeyPair();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("生成RSA密钥对失败", e);
}
}
/**
* 生成 Base64 编码的密钥对
*
* @return 包含公钥和私钥的字符串数组 [publicKey, privateKey]
*/
public static String[] generateKeyPairBase64() {
KeyPair keyPair = generateKeyPair();
return new String[] {
Base64.encodeBase64String(keyPair.getPublic().getEncoded()),
Base64.encodeBase64String(keyPair.getPrivate().getEncoded())
};
}
}
```
## **6. 渠道数据服务**
### **6.1 ChannelDataService.java - 服务接口**
```typescript
package com.example.gateway.service;
import com.example.gateway.model.ChannelDataVo;
import reactor.core.publisher.Mono;
/**
* 渠道数据服务接口
*
* 提供渠道密钥数据的查询功能,支持多级缓存策略。
*
*
缓存策略:
*
* - 一级缓存:本地缓存(可选,使用 Caffeine)
* - 二级缓存:Redis 分布式缓存
* - 三级存储:数据库持久化存储
*
*
* 缓存更新策略:
*
* - 密钥更新时,主动清除相关缓存
* - 设置合理的过期时间,自动失效
* - 支持手动刷新指定渠道的缓存
*
*
* @author Your Name
* @since 1.0.0
*/
public interface ChannelDataService {
/**
* 根据渠道编码获取渠道数据
*
* 优先从缓存获取,缓存未命中则查询数据库并更新缓存。
*
*
查询流程:
*
* Redis 缓存 ──未命中──▶ 数据库查询 ──▶ 写入缓存 ──▶ 返回结果
* │
* └──命中──▶ 直接返回
*
*
* @param code 渠道编码,如 "APP_IOS"
* @return 渠道数据的 Mono,如果渠道不存在则返回 Mono.empty()
*/
Mono getChannelDataByCode(String code);
/**
* 刷新指定渠道的缓存
*
* 当密钥更新时,调用此方法清除旧缓存。
* 下次查询时会自动加载新的密钥数据。
*
* @param code 渠道编码
* @return 操作结果的 Mono
*/
Mono refreshCache(String code);
/**
* 验证渠道是否有效
*
* 检查渠道是否存在且状态为启用。
*
* @param code 渠道编码
* @return true-有效,false-无效或不存在
*/
Mono isValidChannel(String code);
}
```
### **6.2 ChannelDataServiceImpl.java - 服务实现**
```java
package com.example.gateway.service.impl;
import com.alibaba.fastjson2.JSON;
import com.example.gateway.config.SecurityProperties;
import com.example.gateway.model.ChannelDataVo;
import com.example.gateway.service.ChannelDataService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.ReactiveStringRedisTemplate;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 渠道数据服务实现类
*
* 实现了基于 Redis 的分布式缓存策略,保证集群环境下的一致性。
*
*
实现特点:
*
* - 使用响应式 Redis 客户端,与 WebFlux 无缝集成
* - 缓存未命中时,加载数据并自动填充缓存
* - 支持缓存穿透防护(空值缓存)
*
*
* 注意事项:
*
* - 生产环境应替换 loadFromDatabase 方法,接入真实数据库
* - 私钥等敏感数据建议加密存储
*
*
* @author Your Name
* @since 1.0.0
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ChannelDataServiceImpl implements ChannelDataService {
/**
* 响应式 Redis 模板
*/
private final ReactiveStringRedisTemplate redisTemplate;
/**
* 安全配置属性
*/
private final SecurityProperties securityProperties;
/**
* 模拟数据库存储(生产环境请替换为真实数据库访问)
*
* 这里使用 Map 模拟数据库,实际项目中应该:
*
* - 使用 R2DBC 进行响应式数据库访问
* - 或使用传统 JPA/MyBatis 配合 subscribeOn(Schedulers.boundedElastic())
*
*/
private static final Map DATABASE = new ConcurrentHashMap<>();
// 静态初始化模拟数据(仅用于演示)
static {
// 注意:以下密钥仅为示例,生产环境请使用真实密钥
DATABASE.put("APP_IOS", ChannelDataVo.builder()
.code("APP_IOS")
.name("iOS客户端")
.signPublicKey("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQE...") // 替换为真实公钥
.dataPublicKey("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQE...") // 替换为真实公钥
.dataPrivateKey("MIIEvgIBADANBgkqhkiG9w0BAQEFAAOCAQ8...") // 替换为真实私钥
.status(1)
.build());
DATABASE.put("APP_ANDROID", ChannelDataVo.builder()
.code("APP_ANDROID")
.name("Android客户端")
.signPublicKey("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQE...")
.dataPublicKey("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQE...")
.dataPrivateKey("MIIEvgIBADANBgkqhkiG9w0BAQEFAAOCAQ8...")
.status(1)
.build());
}
/**
* {@inheritDoc}
*
* 实现逻辑:
*
* - 生成 Redis 缓存键
* - 尝试从 Redis 获取缓存数据
* - 如果缓存命中,反序列化后返回
* - 如果缓存未命中,从数据库加载并填充缓存
*
*/
@Override
public Mono getChannelDataByCode(String code) {
if (code == null || code.trim().isEmpty()) {
return Mono.empty();
}
String cacheKey = buildCacheKey(code);
return redisTemplate.opsForValue().get(cacheKey)
// 缓存命中:反序列化
.map(json -> {
log.debug("缓存命中: key={}", cacheKey);
return JSON.parseObject(json, ChannelDataVo.class);
})
// 缓存未命中:从数据库加载
.switchIfEmpty(Mono.defer(() -> loadAndCacheChannelData(code, cacheKey)))
// 验证渠道状态
.filter(ChannelDataVo::isEnabled)
.doOnNext(data -> log.debug("获取渠道数据成功: code={}", code))
.doOnError(e -> log.error("获取渠道数据失败: code={}, error={}", code, e.getMessage()));
}
/**
* 从数据库加载数据并填充缓存
*
* @param code 渠道编码
* @param cacheKey Redis缓存键
* @return 渠道数据
*/
private Mono loadAndCacheChannelData(String code, String cacheKey) {
return loadFromDatabase(code)
.flatMap(data -> {
// 序列化并写入缓存
String json = JSON.toJSONString(data);
Duration expireDuration = Duration.ofSeconds(
securityProperties.getCache().getExpireSeconds()
);
return redisTemplate.opsForValue()
.set(cacheKey, json, expireDuration)
.doOnSuccess(success -> log.debug("缓存写入成功: key={}", cacheKey))
.thenReturn(data);
})
// 处理空值(防止缓存穿透)
.switchIfEmpty(Mono.defer(() -> {
log.warn("渠道不存在: code={}", code);
// 可选:缓存空值,设置较短的过期时间
return Mono.empty();
}));
}
/**
* 从数据库加载渠道数据
*
* 生产环境请替换此方法的实现!
*
*
推荐实现方式:
*
{@code
* // 使用 R2DBC
* return channelRepository.findByCode(code);
*
* // 或使用传统 JPA
* return Mono.fromCallable(() -> channelRepository.findByCode(code))
* .subscribeOn(Schedulers.boundedElastic());
* }
*
* @param code 渠道编码
* @return 渠道数据
*/
private Mono loadFromDatabase(String code) {
log.debug("从数据库加载渠道数据: code={}", code);
// 模拟数据库查询(生产环境替换为真实数据库访问)
ChannelDataVo data = DATABASE.get(code);
if (data != null) {
return Mono.just(data);
} else {
return Mono.empty();
}
}
/**
* {@inheritDoc}
*/
@Override
public Mono refreshCache(String code) {
String cacheKey = buildCacheKey(code);
return redisTemplate.delete(cacheKey)
.map(count -> count > 0)
.doOnSuccess(deleted -> {
if (deleted) {
log.info("缓存刷新成功: code={}", code);
} else {
log.debug("缓存不存在,无需刷新: code={}", code);
}
})
.doOnError(e -> log.error("缓存刷新失败: code={}, error={}", code, e.getMessage()));
}
/**
* {@inheritDoc}
*/
@Override
public Mono isValidChannel(String code) {
return getChannelDataByCode(code)
.map(data -> data != null && data.isEnabled())
.defaultIfEmpty(false);
}
/**
* 构建 Redis 缓存键
*
* @param code 渠道编码
* @return 完整的缓存键
*/
private String buildCacheKey(String code) {
return securityProperties.getCache().getKeyPrefix() + code;
}
}
```
## **7. 安全验证过滤器**
```java
package com.example.gateway.filter;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.example.gateway.config.SecurityProperties;
import com.example.gateway.exception.SecurityException;
import com.example.gateway.model.ChannelDataVo;
import com.example.gateway.model.GatewayResponse;
import com.example.gateway.service.ChannelDataService;
import com.example.gateway.utils.RsaUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpRequestDecorator;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.PathMatcher;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.nio.charset.StandardCharsets;
import java.util.Map;
/**
* 安全验证全局过滤器
*
* 这是网关安全体系的核心组件,负责对所有进入的请求进行安全校验。
*
*
处理流程:
*
* 请求进入
* │
* ▼
* ┌─────────────────┐
* │ 1. 白名单检查 │ ── 匹配 ──▶ 直接放行
* └─────────────────┘
* │ 不匹配
* ▼
* ┌─────────────────┐
* │ 2. 读取请求体 │
* └─────────────────┘
* │
* ▼
* ┌─────────────────┐
* │ 3. 验证签名 │ ── 失败 ──▶ 返回 401
* │ (Fail-Fast) │
* └─────────────────┘
* │ 成功
* ▼
* ┌─────────────────┐
* │ 4. 解密数据 │ ── 失败 ──▶ 返回 400
* │ (仅V2模式) │
* └─────────────────┘
* │ 成功
* ▼
* ┌─────────────────┐
* │ 5. 重建请求 │
* │ 传递给下游服务 │
* └─────────────────┘
*
*
* Fail-Fast 原则:
* 验签的计算成本(仅计算哈希+公钥验证)远低于解密(私钥运算)。
* 通过先验签,可以快速过滤无效请求,防止攻击者利用大量无效加密数据
* 消耗服务器资源(DDoS攻击)。
*
*
请求体重建:
* 在 Spring WebFlux 中,请求体(Request Body)是一个响应式流,
* 只能被消费一次。为了让下游服务能够正常读取请求体,我们需要:
*
* - 读取原始请求体
* - 进行验签和解密处理
* - 将处理后的数据重新封装为新的请求体
* - 使用 ServerHttpRequestDecorator 装饰原请求
*
*
* @author Your Name
* @since 1.0.0
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SecurityFilter implements GlobalFilter, Ordered {
/**
* 渠道数据服务,用于获取密钥信息
*/
private final ChannelDataService channelDataService;
/**
* 安全配置属性
*/
private final SecurityProperties securityProperties;
/**
* 路径匹配器,支持 Ant 风格的路径表达式
*/
private final PathMatcher pathMatcher = new AntPathMatcher();
/**
* 加密模式请求头名称
* 值为 "v1" 时仅验签,值为 "v2" 时验签并解密
*/
private static final String ENCRYPT_HEADER = "encrypt";
/**
* V2 加密模式标识
*/
private static final String ENCRYPT_MODE_V2 = "v2";
/**
* 渠道编码在 Exchange Attributes 中的存储键
*
用于在响应过滤器中获取渠道信息
*/
public static final String CHANNEL_DATA_ATTR = "gateway.channel.data";
/**
* 加密模式在 Exchange Attributes 中的存储键
*/
public static final String ENCRYPT_MODE_ATTR = "gateway.encrypt.mode";
/**
* 过滤器主逻辑
*
*
处理流程:
*
* - 白名单检查:匹配则直接放行
* - 读取并缓存请求体
* - 执行安全校验(验签+解密)
* - 重建请求传递给下游
*
*/
@Override
public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
String path = request.getURI().getPath();
// 1. 白名单检查
if (isSkipUrl(path)) {
log.debug("白名单放行: path={}", path);
return chain.filter(exchange);
}
// 2. 读取请求体并处理
return DataBufferUtils.join(request.getBody())
.flatMap(dataBuffer -> {
// 读取请求体内容
byte[] bytes = new byte[dataBuffer.readableByteCount()];
dataBuffer.read(bytes);
// 释放 DataBuffer,防止内存泄漏
DataBufferUtils.release(dataBuffer);
String rawBody = new String(bytes, StandardCharsets.UTF_8);
log.debug("收到请求: path={}, body={}", path, rawBody);
// 3. 执行安全校验
return doSecurityCheck(rawBody, request.getHeaders(), exchange)
.flatMap(newBody -> {
// 4. 重建请求并传递给下游
return chain.filter(buildNewExchange(exchange, newBody));
});
})
// 处理空请求体的情况
.switchIfEmpty(Mono.defer(() -> {
log.warn("请求体为空: path={}", path);
return buildErrorResponse(exchange, HttpStatus.BAD_REQUEST, "请求体不能为空");
}))
// 统一异常处理
.onErrorResume(e -> handleException(exchange, e));
}
/**
* 执行安全校验
*
* 核心校验逻辑,包括:
*
* - 解析请求参数
* - 获取渠道密钥
* - 验证签名(Fail-Fast)
* - 解密数据(V2模式)
*
*
* @param rawBody 原始请求体
* @param headers 请求头
* @param exchange Web交换对象
* @return 处理后的请求体(明文)
*/
private Mono doSecurityCheck(String rawBody, HttpHeaders headers,
ServerWebExchange exchange) {
try {
// 1. 解析请求体
JSONObject bodyJson = JSON.parseObject(rawBody);
if (bodyJson == null) {
return Mono.error(new SecurityException("请求体格式错误", HttpStatus.BAD_REQUEST));
}
// 2. 提取核心参数
String code = bodyJson.getString("code");
String sign = bodyJson.getString("sign");
String businessBody = bodyJson.getString("businessBody");
Long timestamp = bodyJson.getLong("timestamp");
String nonce = bodyJson.getString("nonce");
// 3. 参数校验
if (code == null || sign == null || businessBody == null) {
return Mono.error(new SecurityException("缺少必要参数", HttpStatus.BAD_REQUEST));
}
// 4. 时间戳校验(防重放攻击)
if (!isValidTimestamp(timestamp)) {
return Mono.error(new SecurityException("请求已过期", HttpStatus.BAD_REQUEST));
}
// 5. 获取加密模式
String encryptMode = headers.getFirst(ENCRYPT_HEADER);
// 6. 获取渠道密钥并验证
return channelDataService.getChannelDataByCode(code)
.switchIfEmpty(Mono.error(new SecurityException("非法渠道: " + code, HttpStatus.FORBIDDEN)))
.flatMap(channelData -> {
// 保存渠道信息到 Exchange,供响应过滤器使用
exchange.getAttributes().put(CHANNEL_DATA_ATTR, channelData);
exchange.getAttributes().put(ENCRYPT_MODE_ATTR, encryptMode);
// 7. 验证签名(Fail-Fast)
@SuppressWarnings("unchecked")
Map paramsMap = bodyJson.toJavaObject(Map.class);
boolean verifyResult = RsaUtils.verify(
paramsMap,
channelData.getSignPublicKey(),
sign
);
if (!verifyResult) {
log.warn("签名验证失败: code={}", code);
return Mono.error(new SecurityException("签名验证失败", HttpStatus.UNAUTHORIZED));
}
log.debug("签名验证成功: code={}", code);
// 8. 解密数据(仅V2模式)
String finalBusinessBody = businessBody;
if (ENCRYPT_MODE_V2.equalsIgnoreCase(encryptMode)) {
try {
finalBusinessBody = RsaUtils.decrypt(
businessBody,
channelData.getDataPrivateKey()
);
log.debug("数据解密成功: code={}", code);
} catch (Exception e) {
log.error("数据解密失败: code={}, error={}", code, e.getMessage());
return Mono.error(new SecurityException("数据解密失败", HttpStatus.BAD_REQUEST));
}
}
// 9. 替换 businessBody 为明文
bodyJson.put("businessBody", finalBusinessBody);
return Mono.just(bodyJson.toJSONString());
});
} catch (Exception e) {
log.error("安全校验异常: {}", e.getMessage(), e);
return Mono.error(new SecurityException("请求处理失败: " + e.getMessage(), HttpStatus.BAD_REQUEST));
}
}
/**
* 重建 ServerWebExchange
*
* 将处理后的请求体封装到新的请求对象中。
* 使用 Decorator 模式包装原始请求,只替换 Body 部分。
*
* @param exchange 原始Exchange
* @param newBody 新的请求体内容
* @return 新的Exchange对象
*/
private ServerWebExchange buildNewExchange(ServerWebExchange exchange, String newBody) {
byte[] bodyBytes = newBody.getBytes(StandardCharsets.UTF_8);
// 创建新的 DataBuffer
DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bodyBytes);
// 使用 Decorator 模式重建请求
ServerHttpRequest decoratedRequest = new ServerHttpRequestDecorator(exchange.getRequest()) {
/**
* 重写 getBody 方法,返回新的请求体
*/
@Override
public Flux getBody() {
return Flux.just(buffer);
}
/**
* 重写 getHeaders 方法,更新 Content-Length
*/
@Override
public HttpHeaders getHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.putAll(super.getHeaders());
// 更新 Content-Length,否则下游服务可能读取不完整
headers.setContentLength(bodyBytes.length);
// 移除原有的 Content-Length 头(如果有多个值)
headers.remove(HttpHeaders.CONTENT_LENGTH);
headers.add(HttpHeaders.CONTENT_LENGTH, String.valueOf(bodyBytes.length));
return headers;
}
};
// 构建新的 Exchange
return exchange.mutate()
.request(decoratedRequest)
.build();
}
/**
* 检查是否为白名单URL
*
* @param path 请求路径
* @return true-在白名单中,false-不在
*/
private boolean isSkipUrl(String path) {
return securityProperties.getSkipUrls().stream()
.anyMatch(pattern -> pathMatcher.match(pattern, path));
}
/**
* 验证时间戳有效性
*
* 时间戳与当前时间相差超过5分钟的请求将被拒绝,
* 这是防止重放攻击的重要手段。
*
* @param timestamp 请求时间戳(毫秒)
* @return true-有效,false-无效
*/
private boolean isValidTimestamp(Long timestamp) {
if (timestamp == null) {
return false;
}
long currentTime = System.currentTimeMillis();
long diff = Math.abs(currentTime - timestamp);
// 允许的时间差:5分钟
long maxDiff = 5 * 60 * 1000;
return diff <= maxDiff;
}
/**
* 统一异常处理
*
* @param exchange Web交换对象
* @param e 异常对象
* @return Void Mono
*/
private Mono handleException(ServerWebExchange exchange, Throwable e) {
log.error("请求处理异常: path={}, error={}",
exchange.getRequest().getURI().getPath(), e.getMessage());
HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR;
String message = "服务器内部错误";
if (e instanceof SecurityException se) {
status = se.getStatus();
message = se.getMessage();
}
return buildErrorResponse(exchange, status, message);
}
/**
* 构建错误响应
*
* @param exchange Web交换对象
* @param status HTTP状态码
* @param message 错误消息
* @return Void Mono
*/
private Mono buildErrorResponse(ServerWebExchange exchange,
HttpStatus status, String message) {
exchange.getResponse().setStatusCode(status);
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
GatewayResponse response = GatewayResponse.error(status.value(), message);
String responseBody = JSON.toJSONString(response);
DataBuffer buffer = exchange.getResponse().bufferFactory()
.wrap(responseBody.getBytes(StandardCharsets.UTF_8));
return exchange.getResponse().writeWith(Mono.just(buffer));
}
/**
* 过滤器执行顺序
*
* 返回较小的值表示优先执行。
* 设置为 -100 确保此过滤器在大多数其他过滤器之前执行。
*
* @return 顺序值
*/
@Override
public int getOrder() {
return -100;
}
}
```
## **8. 响应加密过滤器**
```java
package com.example.gateway.filter;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.example.gateway.model.ChannelDataVo;
import com.example.gateway.utils.RsaUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.reactivestreams.Publisher;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferFactory;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.http.server.reactive.ServerHttpResponseDecorator;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.nio.charset.StandardCharsets;
/**
* 响应加密全局过滤器
*
*
对返回给前端的响应数据进行 RSA 加密,仅在 V2 加密模式下生效。
*
*
处理流程:
*
* 下游服务响应
* │
* ▼
* ┌─────────────────────┐
* │ 检查加密模式 │ ── 非V2 ──▶ 原样返回
* └─────────────────────┘
* │ V2模式
* ▼
* ┌─────────────────────┐
* │ 读取响应体 │
* └─────────────────────┘
* │
* ▼
* ┌─────────────────────┐
* │ 提取 data 字段 │
* └─────────────────────┘
* │
* ▼
* ┌─────────────────────┐
* │ 使用前端公钥加密 │
* └─────────────────────┘
* │
* ▼
* ┌─────────────────────┐
* │ 替换 data 为密文 │
* └─────────────────────┘
* │
* ▼
* 返回加密响应
*
*
* 密钥使用说明:
* 使用前端的数据公钥加密响应数据,前端使用对应的私钥解密。
* 这样即使响应被中间人截获,没有前端私钥也无法解密。
*
*
注意事项:
*
* - 只加密 data 字段,code 和 message 保持明文
* - 如果 data 为 null,不进行加密处理
* - 加密失败时返回原始响应,保证服务可用性
*
*
* @author Your Name
* @since 1.0.0
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ResponseEncryptFilter implements GlobalFilter, Ordered {
/**
* V2 加密模式标识
*/
private static final String ENCRYPT_MODE_V2 = "v2";
/**
* 过滤器主逻辑
*
* 使用 Decorator 模式包装响应对象,在写入响应时进行加密处理。
*/
@Override
public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 获取加密模式
String encryptMode = exchange.getAttribute(SecurityFilter.ENCRYPT_MODE_ATTR);
// 非V2模式,直接放行
if (!ENCRYPT_MODE_V2.equalsIgnoreCase(encryptMode)) {
return chain.filter(exchange);
}
// 获取渠道数据
ChannelDataVo channelData = exchange.getAttribute(SecurityFilter.CHANNEL_DATA_ATTR);
if (channelData == null) {
log.warn("渠道数据为空,跳过响应加密");
return chain.filter(exchange);
}
// 使用 Decorator 包装响应
ServerHttpResponseDecorator decoratedResponse = new EncryptingResponseDecorator(
exchange.getResponse(),
channelData
);
return chain.filter(exchange.mutate().response(decoratedResponse).build());
}
/**
* 加密响应装饰器
*
* 继承 ServerHttpResponseDecorator,重写 writeWith 方法实现响应加密。
*/
private class EncryptingResponseDecorator extends ServerHttpResponseDecorator {
private final ChannelDataVo channelData;
public EncryptingResponseDecorator(ServerHttpResponse delegate,
ChannelDataVo channelData) {
super(delegate);
this.channelData = channelData;
}
/**
* 重写响应写入方法
*
*
拦截响应数据,进行加密处理后再写入。
*/
@Override
public Mono writeWith(Publisher extends DataBuffer> body) {
// 只处理 Flux 类型的响应体
if (body instanceof Flux) {
Flux extends DataBuffer> fluxBody = Flux.from(body);
return super.writeWith(
// 缓冲所有数据块
DataBufferUtils.join(fluxBody)
.map(dataBuffer -> {
// 读取原始响应内容
byte[] content = new byte[dataBuffer.readableByteCount()];
dataBuffer.read(content);
DataBufferUtils.release(dataBuffer);
String originalResponse = new String(content, StandardCharsets.UTF_8);
log.debug("原始响应: {}", originalResponse);
// 加密处理
String encryptedResponse = encryptResponseData(originalResponse);
log.debug("加密响应: {}", encryptedResponse);
// 更新 Content-Length
byte[] newContent = encryptedResponse.getBytes(StandardCharsets.UTF_8);
HttpHeaders headers = getDelegate().getHeaders();
headers.setContentLength(newContent.length);
// 返回新的 DataBuffer
DataBufferFactory bufferFactory = getDelegate().bufferFactory();
return bufferFactory.wrap(newContent);
})
);
}
return super.writeWith(body);
}
/**
* 加密响应数据
*
* 只加密 data 字段,保持响应结构不变。
*
* @param jsonBody 原始响应JSON
* @return 加密后的响应JSON
*/
private String encryptResponseData(String jsonBody) {
try {
JSONObject json = JSON.parseObject(jsonBody);
if (json == null) {
return jsonBody;
}
// 获取 data 字段
Object data = json.get("data");
if (data == null) {
log.debug("响应 data 为空,跳过加密");
return jsonBody;
}
// 将 data 序列化为字符串
String dataStr;
if (data instanceof String) {
dataStr = (String) data;
} else {
dataStr = JSON.toJSONString(data);
}
// 使用前端数据公钥加密
String encryptedData = RsaUtils.encrypt(dataStr, channelData.getDataPublicKey());
// 替换 data 字段
json.put("data", encryptedData);
log.debug("响应加密成功: code={}", channelData.getCode());
return json.toJSONString();
} catch (Exception e) {
log.error("响应加密失败,返回原始数据: error={}", e.getMessage(), e);
return jsonBody;
}
}
}
/**
* 过滤器执行顺序
*
*
设置为 -2,确保在 NettyWriteResponseFilter(-1) 之前执行,
* 这样才能正确拦截和修改响应。
*
* @return 顺序值
*/
@Override
public int getOrder() {
return -2;
}
}
```
## **9. 异常处理**
### **9.1 SecurityException.java - 安全异常**
```java
package com.example.gateway.exception;
import lombok.Getter;
import org.springframework.http.HttpStatus;
/**
* 安全校验异常
*
*
在安全校验过程中抛出的异常,携带 HTTP 状态码和错误消息。
*
* @author Your Name
* @since 1.0.0
*/
@Getter
public class SecurityException extends RuntimeException {
/**
* HTTP 状态码
*/
private final HttpStatus status;
/**
* 构造函数
*
* @param message 错误消息
* @param status HTTP状态码
*/
public SecurityException(String message, HttpStatus status) {
super(message);
this.status = status;
}
/**
* 构造函数(带原因)
*
* @param message 错误消息
* @param status HTTP状态码
* @param cause 原始异常
*/
public SecurityException(String message, HttpStatus status, Throwable cause) {
super(message, cause);
this.status = status;
}
}
```
### **9.2 GlobalExceptionHandler.java - 全局异常处理**
```java
package com.example.gateway.exception;
import com.alibaba.fastjson2.JSON;
import com.example.gateway.model.GatewayResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.web.reactive.error.ErrorWebExceptionHandler;
import org.springframework.core.annotation.Order;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.nio.charset.StandardCharsets;
/**
* 全局异常处理器
*
*
捕获网关中所有未处理的异常,统一返回标准格式的错误响应。
*
* @author Your Name
* @since 1.0.0
*/
@Slf4j
@Order(-1)
@Component
public class GlobalExceptionHandler implements ErrorWebExceptionHandler {
@Override
public Mono handle(ServerWebExchange exchange, Throwable ex) {
ServerHttpResponse response = exchange.getResponse();
// 如果响应已经提交,无法再修改
if (response.isCommitted()) {
return Mono.error(ex);
}
// 确定HTTP状态码和错误消息
HttpStatus status;
String message;
if (ex instanceof SecurityException se) {
status = se.getStatus();
message = se.getMessage();
} else if (ex instanceof ResponseStatusException rse) {
status = HttpStatus.valueOf(rse.getStatusCode().value());
message = rse.getReason();
} else {
status = HttpStatus.INTERNAL_SERVER_ERROR;
message = "服务器内部错误";
log.error("未处理异常: ", ex);
}
log.warn("请求异常: path={}, status={}, message={}",
exchange.getRequest().getURI().getPath(), status, message);
// 构建错误响应
response.setStatusCode(status);
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
GatewayResponse errorResponse = GatewayResponse.error(status.value(), message);
String responseBody = JSON.toJSONString(errorResponse);
DataBuffer buffer = response.bufferFactory()
.wrap(responseBody.getBytes(StandardCharsets.UTF_8));
return response.writeWith(Mono.just(buffer));
}
}
```
## **10. 测试示例**
### **10.1 RsaUtilsTest.java - 工具类测试**
```java
package com.example.gateway.utils;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.security.KeyPair;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
/**
* RSA 工具类测试
*
* @author Your Name
* @since 1.0.0
*/
class RsaUtilsTest {
private String publicKey;
private String privateKey;
@BeforeEach
void setUp() {
// 生成测试用密钥对
String[] keyPair = RsaUtils.generateKeyPairBase64();
publicKey = keyPair[0];
privateKey = keyPair[1];
}
@Test
@DisplayName("测试签名原文构建 - 应按字典序排序")
void testBuildSignContent() {
Map params = new HashMap<>();
params.put("code", "APP_IOS");
params.put("timestamp", 1703318400000L);
params.put("businessBody", "{\"userId\":\"10001\"}");
params.put("nonce", "abc123");
params.put("sign", "xxxxx"); // sign 应被排除
String content = RsaUtils.buildSignContent(params);
// 验证排序:businessBody < code < nonce < timestamp
assertTrue(content.startsWith("businessBody="));
assertTrue(content.contains("&code="));
assertTrue(content.contains("&nonce="));
assertTrue(content.endsWith("×tamp=1703318400000"));
assertFalse(content.contains("sign="));
}
@Test
@DisplayName("测试签名与验签")
void testSignAndVerify() {
Map params = new HashMap<>();
params.put("code", "APP_IOS");
params.put("timestamp", System.currentTimeMillis());
params.put("businessBody", "{\"userId\":\"10001\"}");
// 签名
String sign = RsaUtils.sign(params, privateKey);
assertNotNull(sign);
// 验签
boolean result = RsaUtils.verify(params, publicKey, sign);
assertTrue(result);
}
@Test
@DisplayName("测试加密与解密 - 短文本")
void testEncryptAndDecryptShortText() {
String plainText = "Hello, RSA!";
// 加密
String cipherText = RsaUtils.encrypt(plainText, publicKey);
assertNotNull(cipherText);
assertNotEquals(plainText, cipherText);
// 解密
String decryptedText = RsaUtils.decrypt(cipherText, privateKey);
assertEquals(plainText, decryptedText);
}
@Test
@DisplayName("测试加密与解密 - 长文本(测试分段加密)")
void testEncryptAndDecryptLongText() {
// 构建超过 245 字节的长文本
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 100; i++) {
sb.append("这是一段测试文本,用于测试RSA分段加密功能。");
}
String plainText = sb.toString();
// 加密
String cipherText = RsaUtils.encrypt(plainText, publicKey);
assertNotNull(cipherText);
// 解密
String decryptedText = RsaUtils.decrypt(cipherText, privateKey);
assertEquals(plainText, decryptedText);
}
@Test
@DisplayName("测试验签失败 - 数据被篡改")
void testVerifyFailOnTamperedData() {
Map params = new HashMap<>();
params.put("code", "APP_IOS");
params.put("timestamp", System.currentTimeMillis());
params.put("businessBody", "{\"userId\":\"10001\"}");
// 签名
String sign = RsaUtils.sign(params, privateKey);
// 篡改数据
params.put("businessBody", "{\"userId\":\"10002\"}");
// 验签应该失败
boolean result = RsaUtils.verify(params, publicKey, sign);
assertFalse(result);
}
}
```
### **10.2 SecurityFilterTest.java - 过滤器测试**
```java
package com.example.gateway.filter;
import com.alibaba.fastjson2.JSON;
import com.example.gateway.config.SecurityProperties;
import com.example.gateway.model.ChannelDataVo;
import com.example.gateway.model.GatewayRequest;
import com.example.gateway.service.ChannelDataService;
import com.example.gateway.utils.RsaUtils;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.web.server.MockServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
/**
* 安全过滤器测试类
*
* 测试覆盖场景:
*
* - 白名单路径放行
* - 签名验证成功/失败
* - V2 模式数据解密
* - 请求时效性校验
* - 渠道有效性校验
* - 异常情况处理
*
*
* @author Gateway Team
* @version 1.0
*/
@ExtendWith(MockitoExtension.class)
@DisplayName("SecurityFilter 安全过滤器测试")
class SecurityFilterTest {
@Mock
private ChannelDataService channelDataService;
@Mock
private GatewayFilterChain filterChain;
private SecurityFilter securityFilter;
private SecurityProperties securityProperties;
// 测试用密钥对
private KeyPair signKeyPair;
private KeyPair dataKeyPair;
private ChannelDataVo testChannelData;
@BeforeEach
void setUp() {
// 初始化配置
securityProperties = new SecurityProperties();
securityProperties.setEnabled(true);
securityProperties.setSignTimeout(300000L);
securityProperties.setSkipPaths(Arrays.asList("/api/health", "/api/public/**"));
// 创建过滤器实例
securityFilter = new SecurityFilter(securityProperties, channelDataService);
// 生成测试密钥对
signKeyPair = RsaUtils.generateKeyPair();
dataKeyPair = RsaUtils.generateKeyPair();
// 构建测试渠道数据
testChannelData = ChannelDataVo.builder()
.code("TEST_CHANNEL")
.name("测试渠道")
.signPublicKey(RsaUtils.getPublicKeyString(signKeyPair))
.dataPrivateKey(RsaUtils.getPrivateKeyString(dataKeyPair))
.dataPublicKey(RsaUtils.getPublicKeyString(dataKeyPair))
.status(1)
.securityLevel(2)
.build();
// Mock filterChain 默认行为
when(filterChain.filter(any())).thenReturn(Mono.empty());
}
// ==================== 辅助方法 ====================
/**
* 创建有效的网关请求
*/
private GatewayRequest createValidRequest(String businessBody) {
Map params = new HashMap<>();
params.put("code", "TEST_CHANNEL");
params.put("timestamp", System.currentTimeMillis());
params.put("nonce", "test-nonce-" + System.nanoTime());
params.put("businessBody", businessBody);
String sign = RsaUtils.sign(params, RsaUtils.getPrivateKeyString(signKeyPair));
return GatewayRequest.builder()
.code("TEST_CHANNEL")
.timestamp((Long) params.get("timestamp"))
.nonce((String) params.get("nonce"))
.businessBody(businessBody)
.sign(sign)
.build();
}
/**
* 创建 V2 模式的加密请求
*/
private GatewayRequest createV2EncryptedRequest(String plainBusinessBody) {
// 使用后端数据公钥加密业务数据
String encryptedBody = RsaUtils.encrypt(
plainBusinessBody,
RsaUtils.getPublicKeyString(dataKeyPair)
);
Map params = new HashMap<>();
params.put("code", "TEST_CHANNEL");
params.put("timestamp", System.currentTimeMillis());
params.put("nonce", "test-nonce-" + System.nanoTime());
params.put("businessBody", encryptedBody);
String sign = RsaUtils.sign(params, RsaUtils.getPrivateKeyString(signKeyPair));
return GatewayRequest.builder()
.code("TEST_CHANNEL")
.timestamp((Long) params.get("timestamp"))
.nonce((String) params.get("nonce"))
.businessBody(encryptedBody)
.sign(sign)
.build();
}
/**
* 创建 MockServerWebExchange
*/
private MockServerWebExchange createExchange(String path, String body, String encryptMode) {
MockServerHttpRequest.BodyBuilder builder = MockServerHttpRequest
.post(path)
.contentType(MediaType.APPLICATION_JSON);
if (encryptMode != null) {
builder.header("encrypt", encryptMode);
}
MockServerHttpRequest request = builder.body(body);
return MockServerWebExchange.from(request);
}
// ==================== 白名单测试 ====================
@Nested
@DisplayName("白名单路径测试")
class WhitelistTests {
@Test
@DisplayName("精确匹配白名单路径应该直接放行")
void shouldSkipExactWhitelistPath() {
// Given
MockServerWebExchange exchange = createExchange("/api/health", "", null);
// When
Mono result = securityFilter.filter(exchange, filterChain);
// Then
StepVerifier.create(result)
.verifyComplete();
verify(filterChain).filter(exchange);
verifyNoInteractions(channelDataService);
}
@Test
@DisplayName("通配符匹配白名单路径应该直接放行")
void shouldSkipWildcardWhitelistPath() {
// Given
MockServerWebExchange exchange = createExchange("/api/public/info", "", null);
// When
Mono result = securityFilter.filter(exchange, filterChain);
// Then
StepVerifier.create(result)
.verifyComplete();
verify(filterChain).filter(exchange);
}
@Test
@DisplayName("安全校验禁用时应该直接放行")
void shouldSkipWhenSecurityDisabled() {
// Given
securityProperties.setEnabled(false);
MockServerWebExchange exchange = createExchange("/api/secure/data", "", null);
// When
Mono result = securityFilter.filter(exchange, filterChain);
// Then
StepVerifier.create(result)
.verifyComplete();
verify(filterChain).filter(exchange);
}
}
// ==================== 参数校验测试 ====================
@Nested
@DisplayName("参数校验测试")
class ParamValidationTests {
@Test
@DisplayName("空请求体应该返回 400 错误")
void shouldReturn400WhenBodyIsEmpty() {
// Given
MockServerWebExchange exchange = createExchange("/api/secure/data", "", null);
// When
Mono result = securityFilter.filter(exchange, filterChain);
// Then
StepVerifier.create(result)
.verifyComplete();
assertThat(exchange.getResponse().getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST);
verify(filterChain, never()).filter(any());
}
@Test
@DisplayName("无效 JSON 应该返回 400 错误")
void shouldReturn400WhenJsonInvalid() {
// Given
MockServerWebExchange exchange = createExchange(
"/api/secure/data",
"invalid json {",
null
);
// When
Mono result = securityFilter.filter(exchange, filterChain);
// Then
StepVerifier.create(result)
.verifyComplete();
assertThat(exchange.getResponse().getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
@DisplayName("缺少必要参数应该返回 400 错误")
void shouldReturn400WhenMissingRequiredParams() {
// Given - 缺少 sign 字段
String body = """
{
"code": "TEST_CHANNEL",
"timestamp": %d,
"nonce": "test-nonce",
"businessBody": "test"
}
""".formatted(System.currentTimeMillis());
MockServerWebExchange exchange = createExchange("/api/secure/data", body, null);
// When
Mono result = securityFilter.filter(exchange, filterChain);
// Then
StepVerifier.create(result)
.verifyComplete();
assertThat(exchange.getResponse().getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST);
}
}
// ==================== 时效性校验测试 ====================
@Nested
@DisplayName("时效性校验测试")
class TimestampValidationTests {
@Test
@DisplayName("过期的请求应该返回 401 错误")
void shouldReturn401WhenRequestExpired() {
// Given - 创建一个 10 分钟前的请求
GatewayRequest expiredRequest = GatewayRequest.builder()
.code("TEST_CHANNEL")
.timestamp(System.currentTimeMillis() - 600000L) // 10 分钟前
.nonce("test-nonce")
.businessBody("test")
.sign("fake-sign")
.build();
String body = JSON.toJSONString(expiredRequest);
MockServerWebExchange exchange = createExchange("/api/secure/data", body, null);
// When
Mono result = securityFilter.filter(exchange, filterChain);
// Then
StepVerifier.create(result)
.verifyComplete();
assertThat(exchange.getResponse().getStatusCode())
.isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
@DisplayName("未来时间的请求应该返回 401 错误")
void shouldReturn401WhenRequestFromFuture() {
// Given - 创建一个 10 分钟后的请求
GatewayRequest futureRequest = GatewayRequest.builder()
.code("TEST_CHANNEL")
.timestamp(System.currentTimeMillis() + 600000L)
.nonce("test-nonce")
.businessBody("test")
.sign("fake-sign")
.build();
String body = JSON.toJSONString(futureRequest);
MockServerWebExchange exchange = createExchange("/api/secure/data", body, null);
// When
Mono result = securityFilter.filter(exchange, filterChain);
// Then
StepVerifier.create(result)
.verifyComplete();
assertThat(exchange.getResponse().getStatusCode())
.isEqualTo(HttpStatus.UNAUTHORIZED);
}
}
// ==================== 渠道校验测试 ====================
@Nested
@DisplayName("渠道校验测试")
class ChannelValidationTests {
@Test
@DisplayName("无效渠道码应该返回 401 错误")
void shouldReturn401WhenChannelNotFound() {
// Given
when(channelDataService.getChannelDataByCode("UNKNOWN_CHANNEL"))
.thenReturn(Mono.empty());
GatewayRequest request = GatewayRequest.builder()
.code("UNKNOWN_CHANNEL")
.timestamp(System.currentTimeMillis())
.nonce("test-nonce")
.businessBody("test")
.sign("fake-sign")
.build();
String body = JSON.toJSONString(request);
MockServerWebExchange exchange = createExchange("/api/secure/data", body, null);
// When
Mono result = securityFilter.filter(exchange, filterChain);
// Then
StepVerifier.create(result)
.verifyComplete();
assertThat(exchange.getResponse().getStatusCode())
.isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
@DisplayName("已禁用的渠道应该返回 401 错误")
void shouldReturn401WhenChannelDisabled() {
// Given
ChannelDataVo disabledChannel = ChannelDataVo.builder()
.code("TEST_CHANNEL")
.status(0) // 禁用状态
.build();
when(channelDataService.getChannelDataByCode("TEST_CHANNEL"))
.thenReturn(Mono.just(disabledChannel));
GatewayRequest request = createValidRequest("test");
String body = JSON.toJSONString(request);
MockServerWebExchange exchange = createExchange("/api/secure/data", body, null);
// When
Mono result = securityFilter.filter(exchange, filterChain);
// Then
StepVerifier.create(result)
.verifyComplete();
assertThat(exchange.getResponse().getStatusCode())
.isEqualTo(HttpStatus.UNAUTHORIZED);
}
}
// ==================== 签名验证测试 ====================
@Nested
@DisplayName("签名验证测试")
class SignatureValidationTests {
@Test
@DisplayName("有效签名应该通过验证并放行")
void shouldPassWithValidSignature() {
// Given
when(channelDataService.getChannelDataByCode("TEST_CHANNEL"))
.thenReturn(Mono.just(testChannelData));
GatewayRequest request = createValidRequest("{\"userId\": 123}");
String body = JSON.toJSONString(request);
MockServerWebExchange exchange = createExchange("/api/secure/data", body, null);
// When
Mono result = securityFilter.filter(exchange, filterChain);
// Then
StepVerifier.create(result)
.verifyComplete();
verify(filterChain).filter(any());
}
@Test
@DisplayName("无效签名应该返回 401 错误")
void shouldReturn401WithInvalidSignature() {
// Given
when(channelDataService.getChannelDataByCode("TEST_CHANNEL"))
.thenReturn(Mono.just(testChannelData));
// 创建一个带错误签名的请求
GatewayRequest request = GatewayRequest.builder()
.code("TEST_CHANNEL")
.timestamp(System.currentTimeMillis())
.nonce("test-nonce")
.businessBody("test")
.sign("invalid-signature-base64")
.build();
String body = JSON.toJSONString(request);
MockServerWebExchange exchange = createExchange("/api/secure/data", body, null);
// When
Mono result = securityFilter.filter(exchange, filterChain);
// Then
StepVerifier.create(result)
.verifyComplete();
assertThat(exchange.getResponse().getStatusCode())
.isEqualTo(HttpStatus.UNAUTHORIZED);
verify(filterChain, never()).filter(any());
}
@Test
@DisplayName("签名被篡改应该返回 401 错误")
void shouldReturn401WhenSignatureTampered() {
// Given
when(channelDataService.getChannelDataByCode("TEST_CHANNEL"))
.thenReturn(Mono.just(testChannelData));
// 创建有效请求,然后修改 businessBody
GatewayRequest request = createValidRequest("{\"userId\": 123}");
request.setBusinessBody("{\"userId\": 456}"); // 篡改数据
String body = JSON.toJSONString(request);
MockServerWebExchange exchange = createExchange("/api/secure/data", body, null);
// When
Mono result = securityFilter.filter(exchange, filterChain);
// Then
StepVerifier.create(result)
.verifyComplete();
assertThat(exchange.getResponse().getStatusCode())
.isEqualTo(HttpStatus.UNAUTHORIZED);
}
}
// ==================== V2 加密模式测试 ====================
@Nested
@DisplayName("V2 加密模式测试")
class V2EncryptionTests {
@Test
@DisplayName("V2 模式应该成功解密业务数据")
void shouldDecryptBusinessBodyInV2Mode() {
// Given
when(channelDataService.getChannelDataByCode("TEST_CHANNEL"))
.thenReturn(Mono.just(testChannelData));
String plainBusinessBody = "{\"userId\": 123, \"action\": \"query\"}";
GatewayRequest request = createV2EncryptedRequest(plainBusinessBody);
String body = JSON.toJSONString(request);
MockServerWebExchange exchange = createExchange("/api/secure/data", body, "v2");
// When
Mono result = securityFilter.filter(exchange, filterChain);
// Then
StepVerifier.create(result)
.verifyComplete();
verify(filterChain).filter(any());
}
@Test
@DisplayName("V2 模式解密失败应该返回 401 错误")
void shouldReturn401WhenDecryptFails() {
// Given
when(channelDataService.getChannelDataByCode("TEST_CHANNEL"))
.thenReturn(Mono.just(testChannelData));
// 创建一个带无法解密的 businessBody 的请求
Map params = new HashMap<>();
params.put("code", "TEST_CHANNEL");
params.put("timestamp", System.currentTimeMillis());
params.put("nonce", "test-nonce");
params.put("businessBody", "not-a-valid-encrypted-base64");
String sign = RsaUtils.sign(params, RsaUtils.getPrivateKeyString(signKeyPair));
GatewayRequest request = GatewayRequest.builder()
.code("TEST_CHANNEL")
.timestamp((Long) params.get("timestamp"))
.nonce((String) params.get("nonce"))
.businessBody("not-a-valid-encrypted-base64")
.sign(sign)
.build();
String body = JSON.toJSONString(request);
MockServerWebExchange exchange = createExchange("/api/secure/data", body, "v2");
// When
Mono result = securityFilter.filter(exchange, filterChain);
// Then
StepVerifier.create(result)
.verifyComplete();
assertThat(exchange.getResponse().getStatusCode())
.isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
@DisplayName("V1 模式不应该尝试解密")
void shouldNotDecryptInV1Mode() {
// Given
when(channelDataService.getChannelDataByCode("TEST_CHANNEL"))
.thenReturn(Mono.just(testChannelData));
// 明文的 businessBody
GatewayRequest request = createValidRequest("{\"userId\": 123}");
String body = JSON.toJSONString(request);
MockServerWebExchange exchange = createExchange("/api/secure/data", body, "v1");
// When
Mono result = securityFilter.filter(exchange, filterChain);
// Then
StepVerifier.create(result)
.verifyComplete();
verify(filterChain).filter(any());
}
}
// ==================== 集成测试 ====================
@Nested
@DisplayName("完整流程集成测试")
class IntegrationTests {
@Test
@DisplayName("完整 V1 流程:验签通过后应该放行")
void shouldCompleteV1FlowSuccessfully() {
// Given
when(channelDataService.getChannelDataByCode("TEST_CHANNEL"))
.thenReturn(Mono.just(testChannelData));
String businessData = """
{
"userId": 12345,
"action": "queryBalance",
"params": {
"accountType": "savings"
}
}
""";
GatewayRequest request = createValidRequest(businessData);
String body = JSON.toJSONString(request);
MockServerWebExchange exchange = createExchange("/api/account/balance", body, "v1");
// When
Mono result = securityFilter.filter(exchange, filterChain);
// Then
StepVerifier.create(result)
.verifyComplete();
verify(filterChain).filter(any());
verify(channelDataService).getChannelDataByCode("TEST_CHANNEL");
}
@Test
@DisplayName("完整 V2 流程:验签并解密后应该放行")
void shouldCompleteV2FlowSuccessfully() {
// Given
when(channelDataService.getChannelDataByCode("TEST_CHANNEL"))
.thenReturn(Mono.just(testChannelData));
String plainBusinessData = """
{
"userId": 12345,
"action": "transfer",
"params": {
"toAccount": "6222021234567890",
"amount": 1000.00
}
}
""";
GatewayRequest request = createV2EncryptedRequest(plainBusinessData);
String body = JSON.toJSONString(request);
MockServerWebExchange exchange = createExchange("/api/account/transfer", body, "v2");
// When
Mono result = securityFilter.filter(exchange, filterChain);
// Then
StepVerifier.create(result)
.verifyComplete();
verify(filterChain).filter(any());
}
}
}
```
---